Skip to content

fix(certbot): clear stale dns-01 records once per challenge name - #1136

Merged
kvinwang merged 1 commit into
nextfrom
fix/certbot-dns01-multi-san
Aug 26, 2026
Merged

fix(certbot): clear stale dns-01 records once per challenge name#1136
kvinwang merged 1 commit into
nextfrom
fix/certbot-dns01-multi-san

Conversation

@kvinwang

@kvinwang kvinwang commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

A certificate covering both a name and its wildcard -- example.com and
*.example.com -- can never be issued over dns-01. The order fails at
validation:

INFO certbot::acme_client: requesting new certificates for e2e.test, *.e2e.test
WARN certbot::acme_client: no TXT record at _acme-challenge.e2e.test matches the expected value: 1aCTM2z-15KqTmHJA5SsDftKvjSyOMN6KFePkepMg9E
Error: order is invalid: API error: Correct value not found for DNS challenge (urn:ietf:params:acme:error:unauthorized)

The root cause is in authorize(). Those two identifiers produce two separate
authorizations with two separate tokens, but RFC 8555 answers a wildcard
authorization under the bare name, so both TXT values have to be live at
_acme-challenge.example.com at the same time. The loop instead cleared every
TXT record at the challenge name before publishing its own:

self.dns01_client.remove_txt_records(&acme_domain).await?;   // once per authorization
let id = self.dns01_client.add_txt_record(&acme_domain, &dns_value, ttl).await?;

So the second authorization deleted the record answering the first. Whichever
challenge the ACME server validated second passed; the other one had nothing to
find. This is not new -- it predates the instant-acme 0.8 upgrade (#1129) and
the ACME follow-ups (#1130, #1133); see the A/B below.

Wildcard-only orders (*.example.com alone) and single-name orders were never
affected, which is why this survived: one authorization, one record, nothing to
overwrite. That also bounds who hits it today. DistributedCertBot -- the
in-CVM path every current gateway uses -- orders exactly one SAN,
request_new_certificate(&key_pem, &[format!("*.{domain}")]), so it cannot
trigger this. The affected surface is the certbot CLI, where the operator
supplies the SAN list in certbot.toml (docs/deployment.md,
docs/dstack-gateway.md); an apex listed next to its wildcard there fails every
issuance. The fix lives in the shared AcmeClient, so it also covers the
gateway if the apex is ever added to its order.

Fix

Clearing leftovers is a per-name preparation step, not a per-authorization
one. It now runs before the first record published under a challenge name in
this issuance, and subsequent authorizations for the same name publish
alongside rather than replacing:

if needs_purge(challenges, &acme_domain) {
    self.dns01_client.remove_txt_records(&acme_domain).await?;
}

challenges is the list this run has already published, threaded through from
request_new_certificate -- every purge is immediately followed by a push for
that name, so "already in challenges" is exactly "already cleared this run".
No new state, and it stays correct if authorize() is ever entered twice.

Nothing else needs to change: check_dns() already keys its resolvers by
challenge name and asserts each challenge's own value against the answer set,
and cleanup already deletes by record id, so both records for a shared name are
removed on the way out.

Multiple TXT records at one challenge name are what the protocol expects --
RFC 8555 §8.4 validates if any RRSet entry matches -- so this is additive on
the DNS side, not a new requirement on the provider.

Verification

Let's Encrypt staging + real Cloudflare DNS

Same zone (kvin.wang), one fresh subdomain per run so no cached DNS view is
shared, origin/next (55021edbf8) and this branch built identically:

Run Order Result
origin/next dns01test4.kvin.wang + wildcard Error: order is invalid: Incorrect TXT record "w4Vr..." found at _acme-challenge.dns01test4.kvin.wang
this PR dns01test.kvin.wang + wildcard issued -- DNS:*.dns01test.kvin.wang, DNS:dns01test.kvin.wang, issuer (STAGING) Artificial Amaranth YE1

The client's own DNS self-check tells the whole story before Let's Encrypt is
even consulted. origin/next publishes twice under one name and only ever sees
one value live, because the second publish cleared the first:

removing existing TXT record for _acme-challenge.dns01test4.kvin.wang
creating TXT record for _acme-challenge.dns01test4.kvin.wang with TTL 60s
removing existing TXT record for _acme-challenge.dns01test4.kvin.wang     <-- deletes the record just published
creating TXT record for _acme-challenge.dns01test4.kvin.wang with TTL 60s
...
Expected challenge: w4VrSPdKOELlhFoNEt8Jdk5f8ynkehYuGRdIPg3-K6A, actual: w4VrSPdKOELlhFoNEt8Jdk5f8ynkehYuGRdIPg3-K6A
Expected challenge: P3aAc-KWJgJL6cyoE3D6oxm8Tq5RXLlZLvY-YFe11fE, actual: w4VrSPdKOELlhFoNEt8Jdk5f8ynkehYuGRdIPg3-K6A
challenge not found, waiting for 32s ... elapsed=159.9s max_wait=180s
Error: order is invalid: API error: Incorrect TXT record "w4Vr..." found (urn:ietf:params:acme:error:unauthorized)

With the fix, the purge happens once and both values answer from one RRset:

removing existing TXT records for _acme-challenge.dns01test.kvin.wang
creating TXT record for _acme-challenge.dns01test.kvin.wang with TTL 60s
creating TXT record for _acme-challenge.dns01test.kvin.wang with TTL 60s
...
Expected challenge: L367cwx_73OWRA_lIh4ATG2-Fq-zFnPxH_emUlquh78, actual: L367cwx_73OWRA_lIh4ATG2-Fq-zFnPxH_emUlquh78
Expected challenge: oGW1ZxN842fPv4T8CZ0U5V3RGiolTPA8e150h0I0Jyc, actual: oGW1ZxN842fPv4T8CZ0U5V3RGiolTPA8e150h0I0Jyc
created new certificate

Both records are deleted by id afterwards, and the zone holds no
_acme-challenge.dns01test* records once the runs finish.

Pebble + mock Cloudflare API

Faster loop, same A/B, plus the cases the staging runs do not cover:

Order origin/next this PR
["e2e.test", "*.e2e.test"] Error: order is invalid: Correct value not found for DNS challenge issued, DNS:e2e.test, DNS:*.e2e.test
["single.e2e.test"] issued issued, DNS:single.e2e.test

Stale-record handling is unchanged, checked explicitly: a leftover
_acme-challenge.e2e.test TXT record injected before the run
("stale-from-an-aborted-run") is gone afterwards, both challenge records are
published and validated, and the mock API reports zero records once issuance
completes -- so neither the purge nor the cleanup regressed into leaking
records.

Unit tests cover the purge decision itself, and cargo fmt, cargo clippy -p certbot --all-targets -D warnings and cargo test -p certbot are clean.

Note

dns-persist-01 (#1132) is immune by construction -- one persistent record
satisfies every authorization for the zone -- so this only affects the dns-01
path. That PR is unaffected either way; this one is independent of it.

Copilot AI lite review requested due to automatic review settings August 25, 2026 13:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kvinwang
kvinwang merged commit 92ea2ed into next Aug 26, 2026
17 checks passed
@kvinwang
kvinwang deleted the fix/certbot-dns01-multi-san branch August 26, 2026 03:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants